You are viewing a preview of this lesson. Sign in to start learning
Back to Mastering Memory Management and Garbage Collection in .NET

IBufferWriter<T> and Writers

Protocol for writing to growable or fixed buffers without copying

IBufferWriter and Writers

Master high-performance buffer writing in .NET with free flashcards and spaced repetition practice. This lesson covers IBufferWriter<T>, ArrayBufferWriter<T>, PipeWriter, and custom writer implementationsβ€”essential concepts for building zero-allocation, high-throughput systems that minimize memory pressure and maximize performance.

Welcome πŸ’»

When building high-performance .NET applications, controlling how data flows into memory buffers is just as critical as managing the buffers themselves. The IBufferWriter<T> abstraction provides a standardized way to write data incrementally without repeatedly allocating new arrays or creating intermediate copies. This pattern is foundational in modern .NET APIs like System.IO.Pipelines, ASP.NET Core, and serialization libraries.

Unlike traditional approaches where you allocate a buffer, fill it, then potentially resize and copyβ€”IBufferWriter<T> lets the buffer provider decide how to allocate memory, enabling pooling, reuse, and batching strategies that dramatically reduce GC pressure. You'll discover how writers decouple the logic of "what to write" from "where to write it," making your code more testable, flexible, and performant.

Core Concepts πŸ”

What is IBufferWriter?

IBufferWriter<T> is an interface that represents a destination for sequential writing of binary or structured data. It provides a contract between code that produces data and the underlying buffer management strategy:

public interface IBufferWriter
{
    void Advance(int count);
    Memory GetMemory(int sizeHint = 0);
    Span GetSpan(int sizeHint = 0);
}

The workflow is simple but powerful:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚     IBufferWriter WRITE CYCLE            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

    1️⃣ Request buffer space
           |
           ↓
    πŸ“¦ GetSpan(sizeHint)
    πŸ“¦ GetMemory(sizeHint)
           |
           ↓
    2️⃣ Write data to buffer
           |
           ↓
    3️⃣ Notify bytes written
           |
           ↓
    βœ… Advance(count)
           |
           ↓
    πŸ”„ Repeat as needed

Key characteristics:

  • No allocations required by caller - the writer provides the buffer
  • Flexible sizing - sizeHint guides allocation but doesn't guarantee exact size
  • Commit pattern - Advance() confirms how much was actually written
  • Zero-copy design - work directly with Span<T> or Memory<T>

The Three Core Methods

GetSpan(int sizeHint = 0)

Returns a Span<T> to write into. This is the synchronous, stack-only optionβ€”perfect for performance-critical paths where you're writing immediately:

Span<byte> buffer = writer.GetSpan(256);
int bytesWritten = Encoding.UTF8.GetBytes("Hello", buffer);
writer.Advance(bytesWritten);

πŸ’‘ Tip: Use GetSpan() when you're writing synchronously and don't need to pass the buffer to async methods.

GetMemory(int sizeHint = 0)

Returns a Memory<T> to write into. This is the async-friendly option that can be stored and passed across await boundaries:

Memory<byte> buffer = writer.GetMemory(1024);
int bytesRead = await stream.ReadAsync(buffer);
writer.Advance(bytesRead);

Advance(int count)

Commits the number of elements actually written. This must be called after writing to notify the buffer writer:

// ❌ WRONG: Forgot to call Advance
Span<byte> buffer = writer.GetSpan(10);
buffer[0] = 0xFF;
// Writer doesn't know anything was written!

// βœ… RIGHT: Always advance
Span<byte> buffer = writer.GetSpan(10);
buffer[0] = 0xFF;
writer.Advance(1);

⚠️ Critical Rule: Never call Advance() with a count larger than the buffer size you received, and never write beyond the buffer boundaries!

ArrayBufferWriter - The Standard Implementation

ArrayBufferWriter<T> is the built-in, general-purpose implementation that uses a growable array with automatic resizing:

var writer = new ArrayBufferWriter<byte>();

// Write some data
Span<byte> buffer = writer.GetSpan(5);
"Hello".AsSpan().CopyTo(MemoryMarshal.Cast<byte, char>(buffer));
writer.Advance(5);

// Access written data
ReadOnlySpan<byte> written = writer.WrittenSpan;
ReadOnlyMemory<byte> memory = writer.WrittenMemory;

// Reset for reuse
writer.Clear();

Properties you need to know:

PropertyPurposeType
WrittenCountTotal bytes written so farint
WrittenSpanView of written data as SpanReadOnlySpan<T>
WrittenMemoryView of written data as MemoryReadOnlyMemory<T>
CapacityCurrent buffer capacityint
FreeCapacityAvailable space before resizeint

Growth strategy:

Initial: 256 bytes (default)
   |
   ↓ (needs more)
Resize: 512 bytes (2x)
   |
   ↓ (needs more)
Resize: 1024 bytes (2x)
   |
   ↓ continues doubling...

🧠 Memory Device: Think of ArrayBufferWriter<T> as a smart StringBuilder for bytesβ€”it grows automatically and lets you write incrementally without managing the array yourself.

PipeWriter - High-Performance Streaming

PipeWriter is part of System.IO.Pipelines and provides backpressure-aware, high-throughput writing for streaming scenarios:

public async Task WriteDataAsync(PipeWriter writer)
{
    for (int i = 0; i < 1000; i++)
    {
        // Get buffer from pipe
        Memory<byte> buffer = writer.GetMemory(4);
        BinaryPrimitives.WriteInt32LittleEndian(buffer.Span, i);
        writer.Advance(4);
        
        // Flush periodically
        if (i % 100 == 0)
        {
            FlushResult result = await writer.FlushAsync();
            if (result.IsCompleted)
                break; // Reader signaled completion
        }
    }
    
    await writer.CompleteAsync();
}

Key differences from ArrayBufferWriter:

FeatureArrayBufferWriterPipeWriter
Use CaseIn-memory bufferingStreaming/networking
BackpressureNone (grows indefinitely)Built-in flow control
FlushingN/AFlushAsync() required
CompletionN/ACompleteAsync() signals end
ThreadingNot thread-safeSingle writer enforced

πŸ’‘ Tip: Use PipeWriter when writing to sockets, files, or any scenario where the consumer might be slower than the producer.

Real-World Examples πŸ› οΈ

Example 1: Writing a Custom Protocol Message

Let's build a binary message writer for a custom protocol:

public class ProtocolMessageWriter
{
    private readonly IBufferWriter<byte> _writer;
    
    public ProtocolMessageWriter(IBufferWriter<byte> writer)
    {
        _writer = writer;
    }
    
    public void WriteMessage(int messageId, ReadOnlySpan<byte> payload)
    {
        // Calculate total size: 4 bytes (ID) + 4 bytes (length) + payload
        int totalSize = 8 + payload.Length;
        
        // Request buffer space
        Span<byte> buffer = _writer.GetSpan(totalSize);
        
        // Write message ID (4 bytes, little-endian)
        BinaryPrimitives.WriteInt32LittleEndian(buffer, messageId);
        
        // Write payload length (4 bytes, little-endian)
        BinaryPrimitives.WriteInt32LittleEndian(buffer.Slice(4), payload.Length);
        
        // Copy payload
        payload.CopyTo(buffer.Slice(8));
        
        // Commit the write
        _writer.Advance(totalSize);
    }
}

// Usage:
var arrayWriter = new ArrayBufferWriter<byte>();
var protocol = new ProtocolMessageWriter(arrayWriter);

protocol.WriteMessage(42, "Hello"u8);
protocol.WriteMessage(43, "World"u8);

byte[] result = arrayWriter.WrittenSpan.ToArray();

Why this works well:

  • Single allocation for the entire buffer
  • No intermediate byte arrays
  • Works with any IBufferWriter<byte> implementation
  • Easy to test with different writers

Example 2: Custom Pooled BufferWriter

Create a writer that uses ArrayPool<T> for memory efficiency:

public sealed class PooledBufferWriter<T> : IBufferWriter<T>, IDisposable
{
    private T[] _buffer;
    private int _written;
    private readonly ArrayPool<T> _pool;
    
    public PooledBufferWriter(int initialCapacity = 256, ArrayPool<T>? pool = null)
    {
        _pool = pool ?? ArrayPool<T>.Shared;
        _buffer = _pool.Rent(initialCapacity);
        _written = 0;
    }
    
    public int WrittenCount => _written;
    public ReadOnlySpan<T> WrittenSpan => _buffer.AsSpan(0, _written);
    
    public void Advance(int count)
    {
        if (count < 0 || _written + count > _buffer.Length)
            throw new ArgumentException("Invalid advance count");
        _written += count;
    }
    
    public Memory<T> GetMemory(int sizeHint = 0)
    {
        EnsureCapacity(sizeHint);
        return _buffer.AsMemory(_written);
    }
    
    public Span<T> GetSpan(int sizeHint = 0)
    {
        EnsureCapacity(sizeHint);
        return _buffer.AsSpan(_written);
    }
    
    private void EnsureCapacity(int sizeHint)
    {
        int available = _buffer.Length - _written;
        if (available < sizeHint)
        {
            int newSize = Math.Max(_buffer.Length * 2, _written + sizeHint);
            T[] newBuffer = _pool.Rent(newSize);
            _buffer.AsSpan(0, _written).CopyTo(newBuffer);
            _pool.Return(_buffer);
            _buffer = newBuffer;
        }
    }
    
    public void Dispose()
    {
        if (_buffer != null)
        {
            _pool.Return(_buffer);
            _buffer = null!;
        }
    }
}

// Usage:
using var writer = new PooledBufferWriter<byte>();
Span<byte> span = writer.GetSpan(100);
// ... write data ...
writer.Advance(100);
// Buffer automatically returned to pool on dispose

Benefits:

  • Zero heap allocations for the buffer
  • Automatic pooling and return
  • Drop-in replacement for ArrayBufferWriter<T>

Example 3: JSON Writing Without Utf8JsonWriter

Write JSON manually using IBufferWriter<byte> for learning purposes:

public static class SimpleJsonWriter
{
    public static void WriteObject(IBufferWriter<byte> writer, 
        Dictionary<string, object> properties)
    {
        WriteUtf8(writer, "{");
        bool first = true;
        
        foreach (var kvp in properties)
        {
            if (!first) WriteUtf8(writer, ",");
            first = false;
            
            // Write key
            WriteUtf8(writer, "\"");
            WriteUtf8(writer, kvp.Key);
            WriteUtf8(writer, "\":");
            
            // Write value
            switch (kvp.Value)
            {
                case string s:
                    WriteUtf8(writer, "\"");
                    WriteUtf8(writer, s);
                    WriteUtf8(writer, "\"");
                    break;
                case int i:
                    WriteUtf8(writer, i.ToString());
                    break;
                case bool b:
                    WriteUtf8(writer, b ? "true" : "false");
                    break;
            }
        }
        
        WriteUtf8(writer, "}");
    }
    
    private static void WriteUtf8(IBufferWriter<byte> writer, string text)
    {
        int maxBytes = Encoding.UTF8.GetMaxByteCount(text.Length);
        Span<byte> buffer = writer.GetSpan(maxBytes);
        int bytesWritten = Encoding.UTF8.GetBytes(text, buffer);
        writer.Advance(bytesWritten);
    }
}

// Usage:
var writer = new ArrayBufferWriter<byte>();
var data = new Dictionary<string, object>
{
    ["name"] = "Alice",
    ["age"] = 30,
    ["active"] = true
};

SimpleJsonWriter.WriteObject(writer, data);
string json = Encoding.UTF8.GetString(writer.WrittenSpan);
// Result: {"name":"Alice","age":30,"active":true}

Example 4: Batching Writes with Size Limits

Implement a writer that flushes automatically when reaching a size threshold:

public class BatchingWriter<T> : IBufferWriter<T>
{
    private readonly IBufferWriter<T> _innerWriter;
    private readonly Action<ReadOnlySpan<T>> _onBatch;
    private readonly int _batchSize;
    private int _currentBatchCount;
    
    public BatchingWriter(IBufferWriter<T> innerWriter, 
        int batchSize, 
        Action<ReadOnlySpan<T>> onBatch)
    {
        _innerWriter = innerWriter;
        _batchSize = batchSize;
        _onBatch = onBatch;
        _currentBatchCount = 0;
    }
    
    public void Advance(int count)
    {
        _innerWriter.Advance(count);
        _currentBatchCount += count;
        
        if (_currentBatchCount >= _batchSize)
        {
            Flush();
        }
    }
    
    public Memory<T> GetMemory(int sizeHint = 0) => 
        _innerWriter.GetMemory(sizeHint);
    
    public Span<T> GetSpan(int sizeHint = 0) => 
        _innerWriter.GetSpan(sizeHint);
    
    public void Flush()
    {
        if (_currentBatchCount > 0)
        {
            if (_innerWriter is ArrayBufferWriter<T> abw)
            {
                _onBatch(abw.WrittenSpan);
                abw.Clear();
                _currentBatchCount = 0;
            }
        }
    }
}

// Usage:
var baseWriter = new ArrayBufferWriter<byte>();
int batchCount = 0;

var batchWriter = new BatchingWriter<byte>(
    baseWriter,
    batchSize: 1024,
    onBatch: batch => 
    {
        Console.WriteLine($"Batch {++batchCount}: {batch.Length} bytes");
        // Process batch (write to file, send over network, etc.)
    }
);

for (int i = 0; i < 10000; i++)
{
    Span<byte> buffer = batchWriter.GetSpan(4);
    BinaryPrimitives.WriteInt32LittleEndian(buffer, i);
    batchWriter.Advance(4);
}

batchWriter.Flush(); // Flush remaining data

Use cases:

  • Writing to files in optimal chunks
  • Batching network sends
  • Database bulk inserts
  • Logging aggregation

Common Mistakes ⚠️

Mistake 1: Forgetting to Call Advance()

❌ WRONG:
Span<byte> buffer = writer.GetSpan(10);
Encoding.UTF8.GetBytes("test", buffer);
// Forgot Advance() - writer doesn't know anything was written!

βœ… RIGHT:
Span<byte> buffer = writer.GetSpan(10);
int written = Encoding.UTF8.GetBytes("test", buffer);
writer.Advance(written); // Always commit!

Mistake 2: Advancing More Than Written

❌ WRONG:
Span<byte> buffer = writer.GetSpan(100);
int written = SomeMethod(buffer); // Returns 50
writer.Advance(100); // Advanced more than actually written!

βœ… RIGHT:
Span<byte> buffer = writer.GetSpan(100);
int written = SomeMethod(buffer);
writer.Advance(written); // Advance exactly what was written

Mistake 3: Holding References Across Calls

❌ WRONG:
Span<byte> buffer1 = writer.GetSpan(10);
Span<byte> buffer2 = writer.GetSpan(10); // buffer1 may be invalidated!
buffer1[0] = 1; // DANGER: buffer1 might point to wrong memory

βœ… RIGHT:
Span<byte> buffer = writer.GetSpan(10);
buffer[0] = 1;
writer.Advance(1);
// Get new buffer for next write
buffer = writer.GetSpan(10);
buffer[0] = 2;
writer.Advance(1);

🧠 Remember: Treat buffers from GetSpan()/GetMemory() as transientβ€”they're only valid until the next call to those methods or Advance().

Mistake 4: Ignoring sizeHint Semantics

❌ WRONG:
Span<byte> buffer = writer.GetSpan(1000);
if (buffer.Length < 1000)
    throw new Exception("Buffer too small!"); // sizeHint is not a guarantee!

βœ… RIGHT:
Span<byte> buffer = writer.GetSpan(1000);
int available = buffer.Length; // Use what you get
int toWrite = Math.Min(available, dataToWrite.Length);
dataToWrite.Slice(0, toWrite).CopyTo(buffer);
writer.Advance(toWrite);

Mistake 5: Not Disposing PooledBufferWriter

❌ WRONG:
var writer = new PooledBufferWriter<byte>();
// ... use writer ...
// Forgot to dispose - buffer leaked from pool!

βœ… RIGHT:
using var writer = new PooledBufferWriter<byte>();
// ... use writer ...
// Automatically returned to pool

Mistake 6: Concurrent Writes

❌ WRONG:
var writer = new ArrayBufferWriter<byte>();
Parallel.For(0, 100, i =>
{
    Span<byte> buffer = writer.GetSpan(4); // NOT THREAD-SAFE!
    BinaryPrimitives.WriteInt32LittleEndian(buffer, i);
    writer.Advance(4);
});

βœ… RIGHT:
var writer = new ArrayBufferWriter<byte>();
lock (writer) // Or use concurrent collection
{
    for (int i = 0; i < 100; i++)
    {
        Span<byte> buffer = writer.GetSpan(4);
        BinaryPrimitives.WriteInt32LittleEndian(buffer, i);
        writer.Advance(4);
    }
}

⚠️ Important: Neither ArrayBufferWriter<T> nor PipeWriter is thread-safe. Synchronize access or use separate writers per thread.

Key Takeaways 🎯

  1. IBufferWriter<T> decouples data production from buffer management, enabling flexible, testable, high-performance code.

  2. Always call Advance(count) after writingβ€”this commits your data and tells the writer how much you actually wrote.

  3. GetSpan() for sync, GetMemory() for asyncβ€”choose based on whether you need to cross await boundaries.

  4. ArrayBufferWriter<T> is great for in-memory scenarios where you need to accumulate data before processing it.

  5. PipeWriter excels at streaming with built-in backpressure, making it ideal for network protocols and file I/O.

  6. sizeHint is a suggestion, not a guaranteeβ€”always check the actual buffer length returned.

  7. Buffers are transientβ€”don't hold references across multiple GetSpan()/GetMemory() calls.

  8. Pool-based writers minimize allocations by reusing buffers from ArrayPool<T>.

  9. Thread safety requires external synchronizationβ€”standard implementations aren't thread-safe.

  10. Compose writers for powerful patterns like batching, compression, encryption, or protocol framing.

πŸ“‹ Quick Reference Card

ConceptKey Point
GetSpan()Returns Span<T> for synchronous writing
GetMemory()Returns Memory<T> for async-friendly writing
Advance(count)Commits count elements as written (required!)
ArrayBufferWriterBuilt-in growable array implementation
PipeWriterHigh-throughput streaming with backpressure
sizeHintSuggests desired size, not guaranteed
WrittenSpanView of all data written so far
FlushAsync()Sends buffered data (PipeWriter only)
CompleteAsync()Signals end of writing (PipeWriter only)
Thread SafetyNoneβ€”synchronize externally if needed

πŸ“š Further Study